Spring Security 프로젝트 설정 9 - JWT 로그아웃

✒️ 2025-05-28 14:27 내용 수정



로그아웃 동작

참고 자료 : Learn With Ifte's JWT Logout: The Trick You Missed in Spring Security, Bouali Ali's How to Logout from Spring Security - JWT
참고 노트 : Spring Security Logout

  1. 클라이언트에서 Access Token을 로그아웃 요청과 함께 전송한다.
    • Access Token은 Authorization Header에 담는다.
  2. 서버에선 요청에서 Access Token을 먼저 검증하고, 유효한 Access Token일 경우 해당 사용자의 모든 Refresh Token을 DB에서 조회한다.
  3. Refresh Token이 존재할 경우 모든 Refresh Token을 삭제한다.
  4. 사용자가 요청과 함께 보낸 Access Token을 BlackList라는 Table의 Entity에 저장한다.
    • 사용자가 마지막으로 사용한 Access Token으로 다시 요청을 보낼 수 없도록 하는 역할이다.
  5. 서버에서 클라이언트로 로그아웃 결과를 전송한다.

Token 처리에 관한 고민


서버 설정

BlackList Entity와 Repository 추가

  1. BlackList Entity를 추가한다.
    • 이 Entity(테이블)에선 말 그대로 접근 금지 처리되는 Access Token을 저장한다.
    • 로그아웃 수행 후 사용자가 마지막으로 사용한 Access Token으로 다시 요청을 보낼 수 없도록 하기 위해 사용한다.
package com.example.security.token;  
  
import jakarta.persistence.*;  
import lombok.AllArgsConstructor;  
import lombok.Builder;  
import lombok.Data;  
import lombok.NoArgsConstructor;  
  
@Data  
@Entity  
@Builder  
@NoArgsConstructor  
@AllArgsConstructor  
@Table(name = "blacklist")  
public class BlackList {  
  
    @Id  
    // ID는 자동 생성 전략 사용
    @GeneratedValue(strategy = GenerationType.AUTO)  
    private Integer id;  
    private String accessToken;  
}
  1. Entity를 다룰 Repository도 추가한다.
    • Repository의 메소드는 Access Token으로 검색하는 findByAccessToken만 추가하였다.
package com.example.security.token;  
  
import org.springframework.data.jpa.repository.JpaRepository;  
  
import java.util.Optional;  
  
public interface BlackListRepository extends JpaRepository<BlackList, Integer> {  
  
    // Access Token으로 검색  
    Optional<BlackList> findByAccessToken(String token);  
}

JwtService 수정

package com.example.security.config;  
  
import com.example.security.token.BlackList;  
import com.example.security.token.BlackListRepository;  
import com.example.security.token.Token;  
import com.example.security.token.TokenRepository;  
import com.example.security.user.User;  
import io.jsonwebtoken.Claims;  
import io.jsonwebtoken.Jwts;  
import io.jsonwebtoken.SignatureAlgorithm;  
import io.jsonwebtoken.io.Decoders;  
import io.jsonwebtoken.security.Keys;  
import lombok.RequiredArgsConstructor;  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.beans.factory.annotation.Value;  
import org.springframework.security.core.userdetails.UserDetails;  
import org.springframework.stereotype.Service;  
  
import java.security.Key;  
import java.util.Date;  
import java.util.HashMap;  
import java.util.List;  
import java.util.Map;  
import java.util.function.Function;  
  
@Service  
@RequiredArgsConstructor  
@Slf4j  
public class JwtService {  
    // DB와 상호작용하는 token repo    
    private final TokenRepository tokenRepository;  
    // blacklist  
    private final BlackListRepository blackListRepository;  
  
	// ... 생략
  
	// Access Token 유효성 검사  
    public boolean isAccessTokenValid(String token, UserDetails userDetails) {  
        final String username = extractUsername(token);  
  
        // Access Token이 BlackList에 있는지 조회  
        BlackList blackList = blackListRepository.findByAccessToken(token).orElse(null);  
        boolean isBlackListToken = (blackList != null);  
  
        // 토큰의 사용자 정보가 DB의 정보와 일치 여부 + 만료 기한 확인  
        // DB에 사용자 정보가 없다면 여기서 false를 반환하여 유효하지 않음을 확인  
        return (username.equals(userDetails.getUsername()))  
                && !isTokenExpired(token)  
                && !isBlackListToken;  
    }  

	// ... 생략
  
}

SecurityConfig 수정

package com.example.security.config;  
  
import lombok.RequiredArgsConstructor;  
import org.springframework.context.annotation.Bean;  
import org.springframework.context.annotation.Configuration;  
import org.springframework.security.authentication.AuthenticationProvider;  
import org.springframework.security.config.annotation.web.builders.HttpSecurity;  
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;  
import org.springframework.security.config.http.SessionCreationPolicy;  
import org.springframework.security.core.context.SecurityContextHolder;  
import org.springframework.security.web.SecurityFilterChain;  
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;  
import org.springframework.web.cors.CorsConfiguration;  
import org.springframework.web.cors.CorsConfigurationSource;  
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;  
  
import java.util.Collections;  
  
@Configuration  
@EnableWebSecurity  
@RequiredArgsConstructor  
public class SecurityConfig {  
  
    private final JwtAuthenticationFilter jwtAuthFilter;  
    private final AuthenticationProvider authenticationProvider;  
    private final CustomLogoutHandler customLogoutHandler;  
  
    @Bean  
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{  
  
        http  
			// session stateless로 인해 꺼 둠    
			.csrf((auth)->auth.disable())  
			.authorizeRequests()  
			.requestMatchers("/api/v1/auth/**") // 나열된 요청들은    
			.permitAll() // 모두 허용    
			.anyRequest() // 그 외의 모든 요청은    
			.authenticated() // 인증 필요    
			.and()  
			.sessionManagement((session)->  
				session // session state는 저장되면 안되므로 stateless로 설정    
					.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  
			.authenticationProvider(authenticationProvider)  
			.addFilterBefore(jwtAuthFilter,  
					UsernamePasswordAuthenticationFilter.class); // jwt 필터 가동    
        
        // cors 설정    
		http  
			.cors((corsConfigurer)->  
					corsConfigurer
					.configurationSource(corsConfigurationSource()));  
  
        // logout 설정  
        http  
			.logout(log->log.logoutUrl("/api/v1/auth/logout")  
					.addLogoutHandler(customLogoutHandler)  
					.logoutSuccessHandler(  
							(request, response, authentication) 
							-> SecurityContextHolder.clearContext()  
					)  
			);  
  
        return http.build();  
    }  
  
	// ... 생략
}

CustomLogoutHandler 추가

package com.example.security.config;  
  
import com.example.security.token.BlackList;  
import com.example.security.token.BlackListRepository;  
import com.example.security.user.User;  
import com.example.security.user.UserRepository;  
import jakarta.servlet.http.HttpServletRequest;  
import jakarta.servlet.http.HttpServletResponse;  
import lombok.RequiredArgsConstructor;  
import org.springframework.security.core.Authentication;  
import org.springframework.security.web.authentication.logout.LogoutHandler;  
import org.springframework.stereotype.Service;  
  
@Service  
@RequiredArgsConstructor  
public class CustomLogoutHandler implements LogoutHandler {  
    // DB와 상호작용하는 사용자 repo    
    private final UserRepository userRepository;  
    // Token Blacklist  
    private final BlackListRepository blackListRepository;  
    // jwt 서비스  
    private final JwtService jwtService;  
  
    @Override  
    public void logout(  
            HttpServletRequest request,  
            HttpServletResponse response,  
            Authentication authentication)  
    {  
        // 요청에서 Header 가져오기  
        final String authHeader = request.getHeader("Authorization");  
  
        // Authorization Header가 없으면 Access Token이 없으므로 failed        
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {  
            return;  
        }  
  
        // Access Token 추출  
        String token = authHeader.substring(7);  
        String userEmail = jwtService.extractUsername(token);  
  
        // token 내 이메일 확인  
        if (userEmail != null) {  
            // 사용자 검색  
            User user = userRepository.findByEmail(userEmail).orElse(null);  
  
            // token 유효성 검사  
            if (user != null && jwtService.isAccessTokenValid(token, user)) {  
                // Access Token을 BlackList에 추가  
                BlackList blackList = new BlackList();  
                blackList.setAccessToken(token);  
  
                // BlackList 저장  
                blackListRepository.save(blackList);  
  
                // 기존에 db에 저장된 사용자의 모든 Refresh Token 제거  
                jwtService.removeAllUserToken(user);  
            }  
  
        }  
    }  
}

클라이언트 설정

import axios from "axios";  
import { useEffect, useState } from "react";  
import { useNavigate } from "react-router-dom";  
  
function Demo() {  
  
    const [hello, setHello] = useState('');  
    const navigate = useNavigate();  
  
    // token 재발급  
    const refreshToken = async () => {  
        try {  
            const res = await axios.post(
	            '/auth/refresh-token', 
	            {}, 
	            {withCredentials: true}
			);  
            // 응답에서 Access Token 가져와 로컬 변수에 저장  
            const {access_token} = res.data;  
              
            // Access Token을 axios의 header의 Authorization Bearer Schema에 적용  
            axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;  
        } catch (error) {  
            alert("로그인을 다시 해주세요");  
            navigate('/login');  
        }  
    }  
  
    // 결과에 따라 재요청을 처리  
    const fetchWithRetry = async (url, options = {}) => {  
        try {  
            const res = await axios.get(url, options);  
            return res.data;  
        } catch (error) {  
            // 응답이 401이나 403이면 token이 없는 상태  
            if (error.response && 
	            (error.response.status === 401 
	            || error.response.status === 403)) {  
                // token 재발급  
                await refreshToken();  
                // 다시 자원 요청  
                const res = await axios.get(url, options);  
                return res.data;  
            } else {  
                throw error;  
            }  
        }  
    }  
  
    // secured endpoint의 자원 요청하기  
    const getResource = async () => {  
        // 자원 요청  
        try {  
            const data = await fetchWithRetry('/demo-controller');  
            setHello(data);  
        } catch (error) {  
            setHello("Not Authorized");  
        }  
    }  
  
    // 로그아웃  
    const logout = async () => {  
        try {  
            await axios.get("/auth/logout");  
        } catch (error) {  
        }  
    }  
  
    // 최초 렌더링 때만 자원 요청 함수 실행  
    useEffect(()=>{  
        getResource();  
    }, []);  
  
    return(  
        <div>  
            <h2>Demo Page</h2>  
            <button onClick={()=>{navigate("/register")}}>회원가입</button>  
            <button onClick={()=>{navigate("/login")}}>로그인</button>  
            <button onClick={logout}>로그아웃</button>  
            <p>{hello}</p>  
        </div>  
    )  
}  
  
export default Demo;

Test

  1. 애플리케이션을 실행하고, 회원가입과 로그인 절차를 끝내면 서버로부터 Access Token과 Refresh Token을 받는다.

spring_security_logout_jwt 1.png

spring_security_logout_jwt 2.png

  1. DB에서 Token을 확인해보면 방금의 로그인으로 Refresh Token이 저장되어 있다.

spring_security_logout_jwt 3.png

  1. 이제 로그아웃 요청을 보내면 Request Header의 Authorization Bearer Schema에 Access Token이 담긴 것을 확인할 수 있다.

spring_security_logout_jwt 4.png

  1. DB에서 BlackList 테이블을 확인하면 Access Token이 BlackList에 추가된 것을 확인할 수 있다.

spring_security_logout_jwt 5.png

spring_security_logout_jwt 6.png

  1. 이 상태로 새로 고침을 수행해서 /refresh-token 요청을 보내면 이미 클라이언트에 저장된 Refresh Token은 DB에 존재하지 않으므로 요청이 실패하게 된다.

spring_security_logout_jwt 7.png